<script>
import { mapState } from 'pinia';
import { GlTooltipDirective, GlLoadingIcon, GlTooltip, GlButton } from '@gitlab/ui';
import { createAlert } from '~/alert';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileTreeBrowserToggle from '~/repository/file_tree_browser/components/file_tree_browser_toggle.vue';
import { s__, __ } from '~/locale';
import { waitForElement } from '~/lib/utils/dom_utils';
import { InternalEvents } from '~/tracking';
import { joinPaths, buildURLwithRefType, visitUrl } from '~/lib/utils/url_utility';
import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import { TREE_PAGE_SIZE } from '~/repository/constants';
import { getRefType } from '~/repository/utils/ref_type';
import { FOCUS_FILE_TREE_BROWSER_FILTER_BAR, keysFor } from '~/behaviors/shortcuts/keybindings';
import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle';
import { Mousetrap } from '~/lib/mousetrap';
import Shortcut from '~/behaviors/shortcuts/shortcut.vue';
import { useFileTreeBrowserVisibility } from '~/repository/stores/file_tree_browser_visibility';
import { EVENT_OPEN_GLOBAL_SEARCH } from '~/vue_shared/global_search/constants';
import getRefMixin from '~/repository/mixins/get_ref';
import {
  normalizePath,
  dedupeByFlatPathAndId,
  generateShowMoreItem,
  directoryContainsChild,
  shouldStopPagination,
  hasMorePages,
  isExpandable,
  createItemVisibilityObserver,
  observeElements,
} from '../utils';

export default {
  name: 'FileTreeBrowser',
  FOCUS_FILE_TREE_BROWSER_FILTER_BAR,
  directives: {
    GlTooltip: GlTooltipDirective,
  },
  components: {
    GlButton,
    FileRow,
    GlLoadingIcon,
    FileTreeBrowserToggle,
    GlTooltip,
    Shortcut,
  },
  mixins: [InternalEvents.mixin(), getRefMixin],
  props: {
    currentRef: {
      type: String,
      required: true,
    },
    projectPath: {
      type: String,
      required: true,
    },
    refType: {
      type: String,
      required: false,
      default: '',
    },
  },
  data() {
    return {
      directoriesCache: {},
      expandedPathsMap: {},
      loadingPathsMap: {},
      appearedItems: {},
      itemObserver: null,
      activeItemId: null,
    };
  },
  computed: {
    flatFilesList() {
      if (this.isRootLoading) return [];
      return this.buildList('/', 0);
    },
    isRootLoading() {
      return this.isDirectoryLoading('/');
    },
    filterSearchShortcutKey() {
      if (this.shortcutsDisabled) {
        return null;
      }
      return keysFor(FOCUS_FILE_TREE_BROWSER_FILTER_BAR)[0];
    },
    shortcutsDisabled() {
      return shouldDisableShortcuts();
    },
    currentRouterPath() {
      return this.$route.params?.path && normalizePath(this.$route.params.path);
    },
    siblingMap() {
      const map = new Map();
      this.flatFilesList.forEach((item) => {
        const key = `${item.parentPath || ''}-${item.level}`;
        if (!map.has(key)) map.set(key, []);
        map.get(key).push(item.id);
      });
      return map;
    },
    activeStyles() {
      return {
        backgroundColor: 'var(--gl-highlight-target-background-color)',
      };
    },
    ...mapState(useFileTreeBrowserVisibility, ['fileTreeBrowserIsPeekOn']),
  },
  watch: {
    flatFilesList(newList) {
      this.$nextTick(() => this.observeListItems());
      if (newList.length && !newList.find((item) => item.id === this.activeItemId)) {
        this.activeItemId = newList[0].id; // Reset active item to first in list if current active item was filtered out
      }
    },
    fileTreeBrowserIsPeekOn() {
      this.$nextTick(() => this.observeItemVisibility());
    },
    currentRouterPath(newPath, oldPath) {
      if (newPath && newPath !== oldPath) this.expandPathAncestors(newPath);
    },
  },
  mounted() {
    this.observeItemVisibility();
    this.expandPathAncestors(this.currentRouterPath || '/');
    this.mousetrap = new Mousetrap();

    if (!this.shortcutsDisabled) {
      this.mousetrap.bind(keysFor(FOCUS_FILE_TREE_BROWSER_FILTER_BAR), this.triggerFocusFilterBar);
    }
  },
  beforeDestroy() {
    this.itemObserver?.disconnect();
    this.mousetrap.unbind(keysFor(FOCUS_FILE_TREE_BROWSER_FILTER_BAR));
  },
  methods: {
    observeItemVisibility() {
      this.itemObserver?.disconnect();
      const rootElement = this.fileTreeBrowserIsPeekOn
        ? document.querySelector('.file-tree-browser-peek')
        : document.querySelector('.js-static-panel-inner');
      this.itemObserver = createItemVisibilityObserver((itemId, isVisible) => {
        this.appearedItems = { ...this.appearedItems, [itemId]: isVisible };
      }, rootElement);

      this.observeListItems();
    },
    isCurrentPath(path) {
      if (!this.$route.params.path) return path === '/';
      return path === this.currentRouterPath;
    },
    buildList(path, level) {
      const contents = this.getDirectoryContents(path);
      return this.processDirectories({ trees: contents.trees, path, level })
        .concat(this.processSubmodules({ submodules: contents.submodules, path, level }))
        .concat(this.processFiles({ blobs: contents.blobs, path, level }));
    },
    processDirectories({ trees = [], path, level }) {
      const directoryList = [];

      trees.forEach((tree, index) => {
        const treePath = normalizePath(tree.path || tree.name);
        directoryList.push({
          id: `${treePath}-${tree.id}-${index}`,
          path: treePath,
          routerPath: buildURLwithRefType({
            path: joinPaths(
              '/-/tree',
              this.escapedRef,
              treePath.split('/').map(encodeURIComponent).join('/'),
            ),
            refType: this.refType,
          }),
          type: 'tree',
          name: tree.name,
          level,
          opened: Boolean(this.expandedPathsMap[treePath]),
          loading: this.isDirectoryLoading(treePath),
        });

        if (this.shouldRenderShowMore(treePath, path))
          directoryList.push(generateShowMoreItem(tree.id, path, level));

        // Recursively add children for expanded directories
        if (this.expandedPathsMap[treePath]) {
          directoryList.push(...this.buildList(treePath, level + 1));
        }
      });

      return directoryList;
    },
    processFiles({ blobs = [], path, level }) {
      const filesList = [];

      blobs.forEach((blob, index) => {
        const blobPath = normalizePath(blob.path);
        filesList.push({
          id: `${blobPath}-${blob.id}-${index}`,
          fileHash: blob.sha,
          path: blobPath,
          routerPath: buildURLwithRefType({
            path: joinPaths(
              '/-/blob',
              this.escapedRef,
              blobPath.split('/').map(encodeURIComponent).join('/'),
            ),
            refType: this.refType,
          }),
          name: blob.name,
          mode: blob.mode,
          level,
        });

        if (this.shouldRenderShowMore(blobPath, path))
          filesList.push(generateShowMoreItem(blob.id, path, level));
      });

      return filesList;
    },
    processSubmodules({ submodules = [], path, level }) {
      const submodulesList = [];

      submodules.forEach((submodule, index) => {
        const submodulePath = normalizePath(submodule.path || submodule.name);
        submodulesList.push({
          id: `${submodulePath}-${submodule.id}-${index}`,
          fileHash: submodule.sha,
          path: submodulePath,
          webUrl: submodule.webUrl,
          name: submodule.name,
          submodule: true,
          level,
        });

        if (this.shouldRenderShowMore(submodulePath, path))
          submodulesList.push(generateShowMoreItem(submodule.id, path, level));
      });

      return submodulesList;
    },
    async fetchDirectory(dirPath) {
      const path = normalizePath(dirPath);
      const apiPath = path === '/' ? path : path.substring(1);
      const nextPageCursor = this.directoriesCache[path]?.pageInfo?.endCursor || '';

      if ((this.directoriesCache[path] && !nextPageCursor) || this.loadingPathsMap[path]) return;

      this.loadingPathsMap = { ...this.loadingPathsMap, [path]: true };

      try {
        const { projectPath, currentRef, refType } = this;
        const { data } = await this.$apollo.query({
          query: paginatedTreeQuery,
          variables: {
            projectPath,
            ref: currentRef,
            refType: getRefType(refType),
            path: apiPath,
            nextPageCursor,
            pageSize: TREE_PAGE_SIZE,
          },
        });

        const { project } = data;
        const treeData = project?.repository?.paginatedTree?.nodes[0];
        const directoryContents = {
          trees: dedupeByFlatPathAndId(treeData.trees.nodes),
          blobs: dedupeByFlatPathAndId(treeData.blobs.nodes),
          submodules: dedupeByFlatPathAndId(treeData.submodules.nodes),
        };
        const cached = this.directoriesCache[path] || { trees: [], blobs: [], submodules: [] };

        this.directoriesCache = {
          ...this.directoriesCache,
          [path]: {
            trees: [...cached.trees, ...directoryContents.trees],
            blobs: [...cached.blobs, ...directoryContents.blobs],
            submodules: [...cached.submodules, ...directoryContents.submodules],
            pageInfo: project?.repository?.paginatedTree?.pageInfo,
          },
        };
      } catch (error) {
        createAlert({
          message: __('Error fetching data. Please try again.'),
          captureError: true,
          error,
        });
      } finally {
        const newMap = { ...this.loadingPathsMap };
        delete newMap[path];
        this.loadingPathsMap = newMap;
      }
    },

    // Expand all parent directories leading to a path
    async expandPathAncestors(path) {
      await this.fetchDirectory('/');
      const segments = (path || '').split('/').filter(Boolean);
      if (!isExpandable(segments)) return;

      const expand = async (index = 0, currentPath = '', page = 0) => {
        if (index >= segments.length) return;

        const parent = currentPath || '/';
        const segment = segments[index];
        const parentContents = this.getDirectoryContents(parent);

        // Check if segment exists in parent directory
        if (!directoryContainsChild(parentContents, segment)) {
          if (shouldStopPagination(page, this.loadingPathsMap[parent])) return;

          await this.fetchDirectory(parent);

          // Check if found after fetch
          const updatedContents = this.getDirectoryContents(parent);
          if (!directoryContainsChild(updatedContents, segment)) {
            // If more pages exist, try next page
            if (hasMorePages(updatedContents)) {
              await expand(index, currentPath, page + 1);
              return;
            }
            return; // Not found
          }
        }

        // Expand and move to next segment
        const next = `${currentPath}/${segment}`;
        this.expandedPathsMap = { ...this.expandedPathsMap, [next]: true };
        if (!this.directoriesCache[next]) await this.fetchDirectory(next);
        await expand(index + 1, next);
      };

      await expand();
    },

    toggleDirectory(normalizedPath, { toggleClose = true } = {}) {
      if (!this.expandedPathsMap[normalizedPath]) {
        // If directory is collapsed, expand it
        this.expandedPathsMap = {
          ...this.expandedPathsMap,
          [normalizedPath]: true,
        };
        this.fetchDirectory(normalizedPath);
      } else if (toggleClose) {
        // If directory is already expanded and toggleClose=true, collapse it
        const newExpandedPaths = { ...this.expandedPathsMap };
        delete newExpandedPaths[normalizedPath];
        this.expandedPathsMap = newExpandedPaths;
      }
    },

    isDirectoryLoading(path) {
      return Boolean(this.loadingPathsMap[normalizePath(path)]);
    },

    getDirectoryContents(path) {
      return this.directoriesCache[path] || { trees: [], blobs: [], submodules: [] };
    },
    shouldRenderShowMore(itemPath, parentPath) {
      const cached = this.directoriesCache[parentPath];
      if (!cached) return false;

      const { trees, blobs, submodules, pageInfo } = cached;
      const lastItemPath = normalizePath([...trees, ...blobs, ...submodules].at(-1)?.path);
      return itemPath === lastItemPath && pageInfo?.hasNextPage;
    },
    triggerFocusFilterBar() {
      const filterBar = this.$refs.filterInput;
      if (filterBar && filterBar.$el) {
        this.trackEvent('focus_file_tree_browser_filter_bar_on_repository_page', {
          label: 'shortcut',
        });
        this.openGlobalSearch();
      }
    },
    onFilterBarClick() {
      this.trackEvent('focus_file_tree_browser_filter_bar_on_repository_page', {
        label: 'click',
      });

      this.openGlobalSearch();
    },
    async openGlobalSearch() {
      document.dispatchEvent(new CustomEvent(EVENT_OPEN_GLOBAL_SEARCH));
      const searchInput = await waitForElement('#super-sidebar-search-modal #search');
      if (!searchInput) return;
      searchInput.value = '~';
      searchInput.dispatchEvent(new Event('input')); // Ensures the @input handler is called on global_search.vue
    },
    filterInputTooltipTarget() {
      // The input might not always be available (i.e. when the FTB is in collapsed state)
      return this.$refs.filterInput?.$el;
    },
    siblingInfo(item) {
      const siblings = this.siblingMap.get(`${item.parentPath || ''}-${item.level}`);
      return [siblings.length, siblings.indexOf(item.id) + 1];
    },
    onTreeKeydown(event) {
      const items = this.flatFilesList;
      const current = items.findIndex((i) => i.id === this.activeItemId);
      const item = items[current];

      // Enter/Space
      if (event.key === 'Enter' || event.key === ' ') {
        event.preventDefault();
        if (item?.isShowMore) this.handleShowMore(item.parentPath, event);
        if (item?.type === 'tree') this.toggleDirectory(item.path, { toggleClose: false });
        if (item?.submodule && item?.webUrl) visitUrl(item.webUrl);
        if (item?.routerPath && !this.isCurrentPath(item?.path)) this.$router.push(item.routerPath);
        return;
      }

      // Right Arrow
      if (event.key === 'ArrowRight') {
        event.preventDefault();
        if (item?.type === 'tree' && !item.opened) {
          this.toggleDirectory(item.path, { toggleClose: false });
          return;
        }
        const child = items[current + 1];
        if (item?.type === 'tree' && child?.level > item.level) {
          this.activeItemId = child.id;
          this.$nextTick(() => this.$refs.activeItem?.[0]?.focus());
        }
        return;
      }

      // Left Arrow
      if (event.key === 'ArrowLeft') {
        event.preventDefault();
        if (item?.type === 'tree' && item.opened) {
          this.toggleDirectory(item.path);
          return;
        }
        const parent = items
          .slice(0, current)
          .reverse()
          .find((i) => i.level === item.level - 1);
        if (parent) {
          this.activeItemId = parent.id;
          this.$nextTick(() => this.$refs.activeItem?.[0]?.focus());
        }
        return;
      }

      // Arrow keys (Up/Down)
      if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;

      event.preventDefault();
      const move = event.key === 'ArrowDown' ? 1 : -1;
      const next = current + move;

      if (next < 0 || next >= items.length) return;

      this.activeItemId = items[next].id;
      this.$nextTick(() => this.$refs.activeItem?.[0]?.focus());
    },
    observeListItems() {
      this.$nextTick(() => observeElements(this.$refs.fileTreeList, this.itemObserver));
    },
    handleClickSubmodule(webUrl) {
      visitUrl(webUrl);
    },
    async handleShowMore(parentPath, event) {
      const prevItem = event.target.closest('li')?.previousElementSibling;
      await this.fetchDirectory(parentPath);
      await this.$nextTick();
      const nextItem = prevItem?.nextElementSibling;
      if (!nextItem) return;
      this.activeItemId = nextItem.dataset?.itemId;
      nextItem.focus(); // Ensures the next available item is focussed after loading more items
    },
  },
  searchLabel: s__('Repository|Search files (*.vue, *.rb...)'),
};
</script>

<template>
  <section aria-labelledby="tree-list-heading" class="gl-flex gl-h-full gl-flex-col">
    <div class="gl-mb-3 gl-flex gl-items-center gl-gap-3">
      <file-tree-browser-toggle />
      <h3 id="tree-list-heading" class="gl-heading-3 gl-mb-0">
        {{ __('Files') }}
      </h3>
    </div>

    <div class="gl-relative gl-flex">
      <gl-button
        ref="filterInput"
        icon="search"
        data-testid="search-trigger"
        :aria-label="$options.searchLabel"
        :aria-keyshortcuts="filterSearchShortcutKey"
        class="gl-w-full !gl-px-3"
        button-text-classes="gl-flex gl-w-full gl-text-secondary"
        @click="onFilterBarClick"
      >
        <span class="gl-grow gl-text-left">{{ $options.searchLabel }}</span>
      </gl-button>
      <gl-tooltip
        v-if="!shortcutsDisabled"
        custom-class="file-browser-filter-tooltip"
        :target="filterInputTooltipTarget"
      >
        {{ __('Focus on the search bar') }}
        <shortcut
          class="gl-whitespace-nowrap"
          :shortcuts="$options.FOCUS_FILE_TREE_BROWSER_FILTER_BAR.defaultKeys"
        />
      </gl-tooltip>
    </div>
    <gl-loading-icon v-if="isRootLoading" class="gl-mt-5" />
    <nav
      v-else
      class="repository-tree-list gl-mt-2 gl-flex gl-min-h-0 gl-flex-col"
      :aria-label="__('File tree')"
    >
      <ul
        v-if="flatFilesList.length"
        ref="fileTreeList"
        class="gl-h-full gl-min-h-0 gl-flex-grow gl-list-none gl-overflow-y-auto !gl-pl-2"
        role="tree"
        @keydown="onTreeKeydown"
      >
        <li
          v-for="item in flatFilesList"
          :key="`${item.path}-${item.type}`"
          :ref="item.id === activeItemId ? 'activeItem' : undefined"
          :data-item-id="item.id"
          :aria-current="isCurrentPath(item.path)"
          role="treeitem"
          :aria-expanded="item.opened"
          :aria-selected="isCurrentPath(item.path)"
          :aria-level="item.level + 1"
          :aria-setsize="siblingInfo(item)[0]"
          :aria-posinset="siblingInfo(item)[1]"
          :aria-label="item.name"
          :tabindex="item.id === activeItemId ? 0 : -1"
          class="gl-action-neutral-colors gl-rounded-lg focus-visible:gl-focus-inset"
          :style="isCurrentPath(item.path) ? activeStyles : {}"
          @click="activeItemId = item.id"
        >
          <file-row
            v-if="appearedItems[item.id]"
            :file="item"
            :file-url="item.routerPath"
            :level="item.level"
            :opened="item.opened"
            :loading="item.loading"
            show-tree-toggle
            roving-tabindex
            :style="{ '--level': item.level }"
            :class="{
              'tree-list-parent': item.level > 0,
            }"
            :file-classes="isCurrentPath(item.path) ? 'gl-font-bold' : 'gl-text-subtle'"
            class="gl-relative !gl-mx-0 gl-w-fit gl-min-w-full"
            truncate-middle
            @clickTree="(options) => toggleDirectory(item.path, options)"
            @clickSubmodule="handleClickSubmodule"
            @showMore="handleShowMore(item.parentPath, $event)"
          />
          <div v-else data-placeholder-item class="gl-h-7" tabindex="-1"></div>
        </li>
      </ul>
      <p v-else class="gl-my-6 gl-text-center">
        {{ __('No files found') }}
      </p>
    </nav>
  </section>
</template>

<style>
.file-browser-filter-tooltip .tooltip-inner {
  max-width: 210px;
}
</style>
